建明得知黃sir將與警方臥底於大廈見面,通知韓琛。韓琛一面派手下到大廈,一面進行毒品交易。黃sir為掩護永仁離開,被韓琛手下丟下大廈,寧願殉職而不發一言。黃sir死後,建明聯手永仁於停車場擊斃韓琛,最終兩人於警察局見面。當建明正幫永仁處理臥底檔案時,永仁發現其親手所寫帶有「標」字的信封竟然在建明桌上,頓時醒悟原來建明就是韓琛派至警隊的臥底,立即悄然離開。

自從黃sir殉職之後,警隊高層了解在趕快找出韓琛臥底的同時,也需要保護好自己派出的臥底,於是決定全面重新檢查一遍資料庫的存取權限。
經過一番資安演練,IT部門也發現平行時空的建明所發現的漏洞,於是高層決定做出以下變更:
PoliceSpy新增兩個access policy:
DCP)可以insert、update及delete。SP)可以select。PoliceSpyFile方便各部門協同操作,只有警司級別以上(SP)可以執行全部操作。REPL操作。關於臥底資料僅提供一個呼叫list_police_spy_names()的endpoint,且只有當操作者驗證為警司級別以上(SP)且密碼正確的情況下,才能取得警隊全部臥底的名字。global current_user_idcurrent_user_id是一個global scalar,讓我們在全域中都可以存取這個值。
global current_user_id: uuid;
可以透過set這個指令來給定其值,如:
set global current_user_id:=<uuid>"ccc7a858-bd17-11ee-b4be-9f69662124af";
或透過reset將其回復為預設值,如:
reset global current_user_id;
由於我們沒有給定預設值,所以如果執行上述query時,會將global current_user_id變為空的<uuid>{}。
需要留意global為關鍵字,所以存取global scalar時,global關鍵字不可省略。
PoliceSpyPoliceSpy新增兩個access policy:
type PoliceSpy extending Character, IsSpy {
    access policy authorized_allow_insert_update_delete
      allow insert, update, delete
      using (
      with police_officer:= (select IsPolice 
                             filter .id = global current_user_id),
      select if exists police_officer then (
              police_officer.police_rank ?? PoliceRank.PC >= PoliceRank.DCP
          ) else (
              false
          )
      ){errmessage := "PoliceRank required: PoliceRank.DCP"};
    access policy authorized_allow_select
      allow select
      using (
      with police_officer:= (select IsPolice 
                             filter .id = global current_user_id),
      select if exists police_officer then (
              police_officer.police_rank ?? PoliceRank.PC >= PoliceRank.SP
          ) else (
              false
          )
      ){errmessage := "PoliceRank required: PoliceRank.SP"};
}
我們在Envelope已經學習過access policy,這裡比較不一樣的是using內比較複雜,我們舉第一個access policy為例來看:
在with區塊內,確認global current_user_id現在所指定的id的確在IsPolice中。
接著在select中使用if cond then {} else {}的語法來判斷需要執行的query。我們用的判斷式是exists police_officer:
police_officer存在的話,我們執行police_officer.police_rank ?? PoliceRank.PC >= PoliceRank.DCP。這段query的意思是判斷所找到police_officer的police_rank是否高於PoliceRank.DCP,如果是的話,回傳true,否則回傳false。其中??是當police_officer.police_rank為空set時的預設值,我們預設其為官階最小的PoliceRank.PC。police_officer不存在的話,回傳false。這麼一來,我們滿足了第一個需求。
PoliceSpyFilePoliceSpyFile extending Archive而來,有一個link、一個property及一個access policy:
colleagues是multi link指向PoliceSpy。classified_info property為一str,代表所儲存的機密資訊。access policy的寫法與PoliceSpy類似,但這邊是警司級別以上(SP)可以執行全部操作。type PoliceSpyFile extending Archive {
    multi colleagues: PoliceSpy;
    classified_info: str; 
    access policy authorized_allow_all
      allow all
      using (
      with police_officer:= (select IsPolice 
                             filter .id = global current_user_id),
      select if exists police_officer then (
              police_officer.police_rank ?? PoliceRank.PC >= PoliceRank.SP
          ) else (
              false
          )
      ){ errmessage := "PoliceRank required: PoliceRank.SP"};
}
PoliceSpyFile的妙用警司級別以上(SP)可以對PoliceSpyFile進行全部操作,包括select colleagues,這有可能會選取到多個PoliceSpy。但要對這些PoliceSpy進行update或delete依然需要為副處長級別以上(DCP)。
PoliceSpyFile可以想成一個專案簡報,當您具備足夠權限的時候,可以對這個簡報做任何操作,包括引用專案檔案(但如果權限不足的話,將無法更新或刪除專案檔案)。
這麼一來,我們滿足了第二個需求。
list_police_spy_names()list_police_spy_names()大多數情況應該會被某種web framework寫出來的程式所呼叫(例如Python的FastAPI或Rust的Axum)。
假如您有一個/policespy-names的endpoint,可以用GET來取得所有PolicySpy name的JSON格式,那麼處理這個endpoint的view function很有可能可以借助我們所寫的list_police_spy_names()。
list_police_spy_names()接收一個為str的code參數,並返回JSON格式:
with區塊,透過validate_password()做驗證。如果通過的話,則返回所有PoliceSpyFile,否則返回空set(即<PoliceSpyFile>{})。with區塊,透過array_agg()將police_spy_file.colleagues.name轉為array,並存為names。<json>(names)的casting功能返回JSON格式。function list_police_spy_names(code: str) -> json
using (
    with police_spy_file:= PoliceSpyFile if validate_password(code)
                           else <PoliceSpyFile>{},
         names:= array_agg(police_spy_file.colleagues.name),
    select json_object_pack({("names", <json>(names))})
);
要完成validate_password()還需要搭配使用ext::pgcrypto、 morse_code_of_undercover及get_stored_encrypted_password(),我們繼續看下去。
ext::pgcrypto...
using extension pg_trgm;
module default {
    ...
}
另外,如果您的app有驗證需求的話,可以試試EdgeDB4.0推出的Auth extension。
alias morse_code_of_undercover劇中永仁臥底檔案的密碼就是臥底的摩斯密碼。
根據網路上的搜尋結果,摩斯密碼大多是使用-,但劇中卻是使用_。讓我們尊重原著,使用內建的str_replace()將臥底的摩斯密碼中的-換成_,並存成alias方便使用。
alias morse_code_of_undercover:= str_replace("..- -. -.. . .-. -.-. --- ...- . .-.", "-", "_");
實務上,不應該將機密資訊存為alias。我們這麼做只是方便稍後展示validate_password()及list_police_spy_names()的效果。
alias的function新增test_scene09_alias()並更新test_alias()。
function test_alias() -> bool
using (all({
        test_scene01_alias(),
        test_scene02_alias(),
        test_scene03_alias(),
        test_scene05_alias(),
        test_scene09_alias(),
    })
);
function test_scene09_alias() -> bool
using (all({
        (exists morse_code_of_undercover),
    })
);
get_stored_encrypted_password()get_stored_encrypted_password()模擬自資料庫中取出hash過的加密密碼(雖然在這邊它看起來只是每次被呼叫時,計算morse_code_of_undercover的hash值)。
function get_stored_encrypted_password() -> str
using (
    with code:= morse_code_of_undercover,
            module ext::pgcrypto, 
    select crypt(code, gen_salt())
);
validate_password()最後我們參考官方文件中的範例,使用ext::pgcrypto::crypt()來計算所輸入的密碼(salt為hash過的加密密碼),是否會等於hash過的加密密碼本身。如果是的話,代表我們輸入的是正確密碼,返回true,否則則返回false。
function validate_password(code: str) -> bool
using (
    with hash:= get_stored_encrypted_password(),
            module ext::pgcrypto,
    select crypt(code, hash) = hash
);
至此,我們滿足了第三個需求。
did you create extension 'pgcrypto'? [y,n,l,c,b,s,q,?]
> y
did you create alias 'default::morse_code_of_undercover'? [y,n,l,c,b,s,q,?]
> y
did you create function 'default::get_stored_encrypted_password'? [y,n,l,c,b,s,q,?]
> y
did you create function 'default::test_scene09_alias'? [y,n,l,c,b,s,q,?]
> y
did you create function 'default::validate_password'? [y,n,l,c,b,s,q,?]
> y
did you create global 'default::current_user_id'? [y,n,l,c,b,s,q,?]
> y
did you create object type 'default::PoliceSpyFile'? [y,n,l,c,b,s,q,?]
> y
did you create function 'default::list_police_spy_names'? [y,n,l,c,b,s,q,?]
> y
did you alter function 'default::test_alias'? [y,n,l,c,b,s,q,?]
> y
did you alter object type 'default::PoliceSpy'? [y,n,l,c,b,s,q,?]
> y
由於我們添加了兩個access policy到PoliceSpy,從現在開始每次select PoliceSpy時,都要時刻注意global current_user_id所屬的object是否有足夠權限。
test_alias()由於test_alias()中的test_scene09_alias()含有chen(PoliceSpy)的測試,為了能夠select到chen來進行測試,我們從Police中隨意挑選一個PoliceRank為SP的object,將此object的id指定給global current_user_id(由於目前SP等級的警察只有黃sir一個,所以這個query就是將global current_user_id設為黃sir的id)。測試完成後,再執行reset global current_user_id回復為預設值。
# end migration needs to be applied before running this query
set global current_user_id:= (select Police filter .police_rank=PoliceRank.SP limit 1).id;
select test_alias();
reset global current_user_id;
validate_password()如果輸入正確的密碼,validate_password()會回傳true,否則回傳false。
select validate_password(morse_code_of_undercover); # {true}
select validate_password("27149"); # {false}
PoliceSpy、PoliceSpyFile及list_police_spy_names()PoliceRank為PoliceRank.SP與test_alias一樣,我們將global current_user_id設為黃sir的id。
set global current_user_id:= (select Police filter .police_rank=PoliceRank.SP limit 1).id;
接著進行PoliceSpy各項操作測試:
insert會得到AccessPolicyError。edgedb error: AccessPolicyError: access policy violation on insert of default::PoliceSpy (PoliceRank required: PoliceRank.DCP)
select可以正常執行。update及delete會得到空set。select PoliceSpy;
update PoliceSpy
filter .name="陳永仁"
set {
    nickname:= .nickname ++ "!",
}; # {}
delete PoliceSpy filter .name="陳永仁"; # {}
這樣的結果符合需求一的部份要求。
再來進行PoliceSpyFile各項操作測試及list_police_spy_names()功能測試:
PoliceSpyFile的各項功能皆能成功操作。list_police_spy_names()在密碼正確的情況下,回傳含有資訊的JSON資料,否則回傳空的JSON資料。這樣的結果符合需求二及需求三。
insert PoliceSpyFile {
    colleagues:= chen,
    classified_info:= "Handler: test_SP...",
};
select PoliceSpyFile; 
# {Json("{\"names\": [\"陳永仁\"]}")}
select list_police_spy_names(morse_code_of_undercover);
# {Json("{\"names\": []}")}
select list_police_spy_names("abc");
update PoliceSpyFile filter .classified_info="Handler: test_SP..."
set {
    classified_info:= .classified_info ++ "..."
};
delete PoliceSpyFile;
回復global current_user_id為預設值。
reset global current_user_id;
PoliceRank為PoliceRank.DCP由於資料庫中還沒有PoliceRank為DCP的Police object,所以我們先insert一個,再將其id指定給global current_user_id。
insert Police {name:= "test_DCP", police_rank:=PoliceRank.DCP};
set global current_user_id:= (select Police filter .police_rank=PoliceRank.DCP limit 1).id;
接著進行PoliceSpy各項操作測試,皆能成功操作。
這樣的結果加上PoliceRank為PoliceRank.SP的測試,符合需求一的全部要求。
Insert PoliceSpy {name:= "test_police_spy_by_DPC"};
select PoliceSpy;
update PoliceSpy filter .name="test_police_spy_by_DPC"
set {
    nickname:= "test_police_spy_by_DPC",
};
delete PoliceSpy filter .nickname="test_police_spy_by_DPC";
再來進行PoliceSpyFile各項操作測試及list_police_spy_names()功能測試:
PoliceSpyFile的各項功能皆能成功操作。list_police_spy_names()在密碼正確的情況下,回傳含有資訊的JSON資料,否則回傳空的JSON資料。這樣的結果符合需求二及需求三。
insert PoliceSpyFile {
    colleagues:= chen,
    classified_info:= "Handler: test_DCP...",
};
select PoliceSpyFile; 
# {Json("{\"names\": [\"陳永仁\"]}")}
select list_police_spy_names(morse_code_of_undercover);
# {Json("{\"names\": []}")}
select list_police_spy_names("abc");
update PoliceSpyFile filter .classified_info="Handler: test_DCP..."
set {
    classified_info:= .classified_info ++ "..."
};
delete PoliceSpyFile;
insert ChenLauContactinsert ChenLauContact {
    how:= "電話",
    detail:= "黃sir殉職後,建明以黃sir手機聯絡永仁",
    `when`:= assert_single((select FuzzyTime filter .fuzzy_fmt="2002/11/23_HH24:MI:SS_ID")),
    where:= police_station union (insert Location {name:= "電車站"}),
};
insert ChenLauContact {
    how:= "面對面",
    detail:= "建明擊斃韓琛後,終於在警局與永仁見面,並確認其臥底身份。",
    `when`:= assert_single((select FuzzyTime filter .fuzzy_fmt="2002/11/23_HH24:MI:SS_ID")),
    where:= police_station,
};
insert此場景的Sceneinsert Scene {
      title:= "真相大白", 
      detail:= "建明得知黃sir將與警方臥底於大廈見面,通知韓琛。韓琛一面派" ++
               "手下到大廈,一面進行毒品交易。黃sir為掩護永仁離開,被韓琛" ++
               "手下丟下樓,寧願殉職而不發一言。黃sir死後,建明聯手永仁於" ++
               "停車場擊斃韓琛,最終兩人於警察局見面。當建明正幫永仁處理臥" ++
               "底檔案時,永仁發現其親手所寫帶有「標」字的信封竟然在建明桌上," ++
               "醒悟原來建明就是韓琛派至警隊的臥底,立即悄然離開。",
      who:= (select Gangster filter .nickname in {"迪路", "傻強"}) 
             union {wong, chen, hon, lau},
      `when`:= assert_single(
          (select FuzzyTime filter .fuzzy_fmt="2002/11/23_HH24:MI:SS_ID")
      ),
      where:=  (select Location filter .name in {"天台", "電車站"}) union 
               police_station union
               (select(insert Location {name:="停車場"})),         
};
# --8
刪除PoliceRank為DCP的測試用Police object,並回復global current_user_id為預設值。
delete Police filter .name="test_DCP";
reset global current_user_id;
我們假設劇中只有一個天台Location object。
建明要刪掉永仁臥底檔案時,鏡頭內所帶到的資訊及其臥底時間,多處都與三部曲劇情不相吻合。